【第2929期】React Refs: 从访问 DOM 到命令式 API
前言
终于终于要放假了。今日前端早读课文章由 @ikoofe 翻译分享,公号:KooFE 前端团队授权。
前端早读课:重视信息价值密度
正文从这开始~~
React 一个美妙之处在于它通过抽象降低了操作真实 DOM 的复杂度。这让我们不必手动去查询元素,也不必思考如何向这些元素添加类,也不必再苦于浏览器的不一致性,而是让我们专心编写组件并专注于用户体验。然而,仍然会有一些场景(虽然很少!)需要我们访问 DOM。
而当涉及到 DOM 时,最重要的是理解并学习如何正确使用 Ref 以及它的周边。因此在本文中,我们要弄清楚为什么需要访问 DOM、Ref 如何帮助我们实现这一点、useRef、forwardRef 和 useImperativeHandle 是什么,以及如何正确使用它们。此外,我们还要讨论如何避免使用 forwardRef 和 useImperativeHandle,但是同时还要拥有它们提供的能力。
除了上述内容之外,我们还将学习如何在 React 中实现命令式 API!
在 React 中使用 useRef 访问 DOM
假设我想为我组织的会议实现一个注册表单。我希望在给人们发送详细信息之前,他们要先提供自己的姓名、电子邮件和 Twitter 账号。我想要 “姓名” 和 “电子邮件” 字段是必填的。但我不想在人们尝试提交空字段时显示一些恼人的红色边框,我希望表单很酷。因此,我希望聚焦空字段并将其稍微晃动一下以吸引注意,当然这些只是为了好玩。
虽然,React 给我们提供了很多东西,但它并没有提供一切。像 “手动聚焦一个元素” 这样的东西,并不是它的一部分。为此,需要使用原生 JavaScript API 技能,来访问实际的 DOM 元素。
在没有 React 的世界中,我们会做这样的事情:
const element = document.getElementById("bla");
然后我们进行手动聚焦:
element.focus();
或者进行滚动:
element.scrollIntoView();
在 React 世界中使用原生 DOM API 的一些典型场景包括:
在渲染后手动聚焦元素,例如表单中的输入字段。
在显示类似弹出窗口的元素时,检测组件外部的点击。
在出现在屏幕上的元素后手动滚动到一个元素。
计算屏幕上组件的大小和边界,以便在正确的位置展示一些内容,比如 tooltip。
尽管从技术上讲,我们现在仍然可以使用 getElementById。但是为了让我们既不需要在各个角落里设置 id,也不需要了解到底层 DOM 结构,React 提供了一种更强大的访问元素的方式:refs。
Ref 只是一个可变对象,React 只在重新渲染期间保留对其的引用。它并不会触发重新渲染,因此它并不能替代 state,当然也不要试图用它来替代 state。关于这两者之间的区别的更多细节可以在文档中找到。
它是使用 useRef 钩子创建的:
const Component = () => {
// 创建一个 ref,并将其默认值设置为 null
const ref = useRef(null);
return ...
}
而存储在 Ref 中的值只能通过 “current” 属性来访问。实际上,我们可以在其中存储任何东西!例如,我们可以存储一个包含一些来自 state 的值的对象:
const Component = () => {
const ref = useRef(null);
useEffect(() => {
// 为 ref 赋值,并覆盖默认值
ref.current = {
someFunc: () => {...},
someValue: stateValue,
}
}, [stateValue])
return ...
}
或者,可以将这个 Ref 分配给任何 DOM 元素和一些 React 组件:
const Component = () => {
const ref = useRef(null);
// 将 ref 分配给一个 input 元素
return <input ref={ref} />
}
现在,如果我在 useEffect 中打印 ref.current
(它只有在组件呈现后才可用),它将输出一个 input 元素,其效果与 getElementById 一样:
const Component = () => {
const ref = useRef(null);
useEffect(() => {
// 这将是一个指向 input DOM 元素的引用!
// 它与我对其执行 getElementById 时得到的元素完全相同
console.log(ref.current);
});
return <input ref={ref} />
}
现在,为了完全实现注册表单组件,我们可以这样做:
const Form = () => {
const [name, setName] = useState('');
const inputRef = useRef(null);
const onSubmitClick = () => {
if (!name) {
// 如果有人尝试提交空 name,则聚焦该 input 字段
ref.current.focus();
} else {
// 在此处提交数据!
}
}
return <>
...
<input onChange={(e) => setName(e.target.value)} ref={ref} />
<button onClick={onSubmitClick}>提交表单!</button>
</>
}
将 input 的值存储在 state 中,为所有 input 创建 Ref,在单击 “提交” 按钮时,将检查它们的值是否为空,如果为空,则聚焦所对应的 input。
将 ref 作为 prop 从父组件传递到子组件
在实际应用中,当然不会只创建一个上面那样的组件。更有可能的是,我们会将输入框提取出来成为一个单独的组件,以便在多个表单中重复使用;它可以封装和控制其自己的样式,甚至可以具有一些其他功能,例如在顶部添加标签或在右侧添加图标。
const InputField = ({ onChange, label }) => {
return <>
{label}<br />
<input type="text" onChange={(e) => onChange(e.target.value)} />
</>
}
但是,错误处理和提交功能仍将在 Form 组件中,而不是在 InputField 组件中。
const Form = () => {
const [name, setName] = useState('');
const onSubmitClick = () => {
if (!name) {
// 处理空名称
} else {
// 在此提交数据!
}
}
return <>
...
<InputField label="name" onChange={setName} />
<button onClick={onSubmitClick}>提交表单!</button>
</>
}
怎么才能告知 Form 组件中的输入框进行 “自我聚焦”?在 React 中控制数据和行为的 “正常” 方式是将 props 传递给组件并监听回调。我可以尝试将 “focusItself” prop 传递给 InputField,我可以从 false 更改为 true,但这只能工作一次。
// 这里只是为了方便演示,实际应用中不要这样做!
const InputField = ({ onChange, focusItself }) => {
const inputRef = useRef(null);
useEffect(() => {
if (focusItself) {
// 如果 focusItself prop 更改,将聚焦于 input
// 仅在 false 更改为 true 时有效
ref.current.focus();
}
}, [focusItself])
// 其余代码相同
}
我可以添加一个 “onBlur” 回调,并在输入框失去焦点时将 focusItself prop 重置为 false。当然方法有很多种,接下来介绍另外一种方法。
我们可以在一个组件(Form)中创建 Ref,将其作为 prop 传递到另一个组件(InputField)中,并在那里将其附加到底层 DOM 元素上。在这里,Ref 只是一个可变对象。Form 中正常创建 Ref:
const Form = () => {
// 在 Form 组件中创建 Ref
const inputRef = useRef(null);
...
}
InputField 组件将具有一个接受 ref 的 prop,然后输入框 input 接受 ref。只是 Ref 不在 InputField 中创建的,而是来自的 props:
const InputField = ({ inputRef }) => {
// 其余代码相同
// 将prop中的ref传递给内部的输入组
return <input ref={inputRef} ... />
}
Ref 是一种可变对象,正是为了这种场景而设计的。当我们将其传递给一个元素时,React 在底层会对其进行更改。要被更改的对象是在 Form 组件中声明的。因此,一旦 InputField 被渲染,Ref 对象就会被更改,我们的 Form 将能够访问 inputRef.current 中的 input DOM 元素:
const Form = () => {
// 在 Form 组件中创建 Ref
const inputRef = useRef(null);
useEffect(() => {
// 渲染在 InputField 内部的 “input” 元素将出现在此处
console.log(inputRef.current);
}, []);
return (
<>
{/* 将 ref 作为 prop 传递给输入字段组件 */}
<InputField inputRef={inputRef} />
</>
)
}
或者在 submit 回调中,我们可以调用 inputRef.current.focus()
,完全与之前的代码相同。
使用 forwardRef 将 ref 从父组件传递给子组件
如果你想知道为什么我将 prop 命名为 inputRef 而不仅仅是 ref:这并不是那么简单。ref 不是一个真正的属性,它是一种 “保留” 名称。在旧的时代,当我们编写类组件时,如果我们将 ref 传递给一个类组件,这个组件的实例将是该 Ref 的 .current
值。
但是函数组件没有实例。所以,我们只会在控制台中得到一个警告:“函数组件无法被给予 Ref。访问该 Ref 的尝试将失败。您是否想使用 React.forwardRef()
?”
const Form = () => {
const inputRef = useRef(null);
// 如果我们这样做,我们会在控制台中得到一个警告
return <InputField ref={inputRef} />
}
为了使其起作用,我们需要向 React 表示这个 ref 是有意义的,我们想要对其进行操作。我们可以使用 forwardRef 函数来实现:它接受我们的组件并将 ref 属性的 ref 注入到组件函数的第二个参数中,就在 props 之后。
// 通常,我们只会有 props 在这里
// 但是我们用 forwardRef 包装了组件的函数
// 它将第二个参数 ref 注入到组件中
// 如果它被组件的消费者传递给了这个组件
const InputField = forwardRef((props, ref) => {
// 其余的代码是相同的
return <input ref={ref} />
})
为了更好的可读性,我们甚至可以将上面的代码分成两个变量:
const InputFieldWithRef = (props, ref) => {
// 其余的代码是相同的
}
// 这个将被表单使用
export const InputField = forwardRef(InputFieldWithRef);
现在,表单可以像将 ref 传递给常规 DOM 元素一样,将 ref 传递给 InputField 组件:
return <InputField ref={inputRef} />
使用 forwardRef 还是只传递 ref 作为属性,只是个人偏好的问题:最终的结果是一样的。
用 useImperativeHandle 实现命令式 API
好了,聚焦输入框的 Form 组件已经实现了,但是我们还没有完成我们想要的酷炫表单呢。还记得我们要在发生错误时抖动输入框吗?原生的 Javascript API 中没有 element.shake () 这样的函数,所以访问 DOM 元素也无法解决这个问题😢。
我们可以很容易地通过 CSS 动画实现:
const InputField = () => {
// 存储我们是否需要抖动的状态
const [shouldShake, setShouldShake] = useState(false);
// 当需要抖动时,只需要添加类名即可,CSS 会处理动画
const className = shouldShake ? "shake-animation" : '';
// 当动画完成后,将状态重新设置为 false,这样我们可以重新开始,如果需要的话
return <input className={className} onAnimationEnd={() => setShouldShake(false)} />
}
但是如何触发它呢?就像之前的聚焦一样,我可以想出一些使用 props 的创造性解决方案,但它看起来很奇怪,会极大地复杂化表单。特别是考虑到我们通过 ref 处理聚焦,所以我们也有两个解决方案,用于处理完全相同的问题。如果我能在这里做些像 InputField.shake()
和 InputField.focus()
这样的事情就好了!
说到聚焦,为什么 Form 组件仍然需要处理 DOM API 才能触发它?难道这不应该是 InputField 的责任的吗?为什么表单甚至可以访问底层的 DOM 元素 - 它基本上泄漏了内部实现细节。表单组件不应该关心我们使用哪些 DOM 元素,或者我们是否使用 DOM 元素或完全不同的东西。分离关注点,你懂的。
看起来是时候为我们的 InputField 组件实现一个合适的命令式 API 了。现在,React 是声明式的,并希望我们按照规定的方式编写代码。但有时我们需要以命令式的方式触发某些操作。React 可能会为此提供一个出路:useImperativeHandle 钩子。
这个钩子略微令人费解,我不得不两次阅读文档,并且尝试在实际的 React 代码中查看其实现,才真正理解它在做什么。但本质上,我们只需要两件事:决定我们的命令式 API 应该如何实现,以及要如何附加到的 Ref。对于我们的 input,它很简单:只需要 .focus()
和 .shake()
函数作为 API,我们已经了解所有关于 refs 的知识。
// 这是我们的 API 可能的样子
const InputFieldAPI = {
focus: () => {
// 在这里执行焦点魔法
},
shake: () => {
// 触发抖动
}
}
这个 useImperativeHandle 钩子只是将这个对象附加到 Ref 对象的 “current” 属性上,这是它的实现方式:
const InputField = () => {
useImperativeHandle(someRef, () => ({
focus: () => {},
shake: () => {},
}), [])
}
第一个参数 - 是我们的 Ref,它可以在组件自身中创建,通过 props 传递或通过 forwardRef 传递。第二个参数是一个返回对象的函数 - 这是将作为 inputRef.current 可用的对象。第三个参数与任何其他 React 钩子一样是依赖项数组。
对于我们的组件,让我们将 ref 作为 apiRef 属性明确传递。唯一剩下的事情就是实现实际的 API。为此,我们需要另一个 ref - 这次是 InputField 内部的,以便我们可以将其附加到输入 DOM 元素并像往常一样触发聚焦:
// 将我们用作命令式 API 的 Ref 作为属性传递
const InputField = ({ apiRef }) => {
// 创建另一个ref - 内部的 Input 组件
const inputRef = useRef(null);
// 将我们的API“合并”到 apiRef 中
// 返回的对象将可用于作为 apiRef.current 使用
useImperativeHandle(apiRef, () => ({
focus: () => {
// 仅在附加到 DOM 对象的内部 ref 上触发焦点
inputRef.current.focus()
},
shake: () => {},
}), [])
return <input ref={inputRef} />
}
至于 “抖动”,我们只需要触发状态更新:
// 将我们用作命令式 API 的 Ref 作为属性传递
const InputField = ({ apiRef }) => {
// 记住我们用于抖动的状态吗?
const [shouldShake, setShouldShake] = useState(false);
useImperativeHandle(apiRef, () => ({
focus: () => {},
shake: () => {
// 在这里触发状态更新
setShouldShake(true);
},
}), [])
return ...
}
最后一步!我们的表单只需创建一个引用,将其传递给 InputField,就能够轻松使用 inputRef.current.focus()
和 inputRef.current.shake()
,而无需担心它们的内部实现!
const Form = () => {
const inputRef = useRef(null);
const [name, setName] = useState('');
const onSubmitClick = () => {
if (!name) {
// 如果名称为空则聚焦
inputRef.current.focus();
// 并且抖起来
inputRef.current.shake();
} else {
// 这里提交表单
}
}
return <>
...
<InputField label="name" onChange={setName} apiRef={inputRef} />
<button onClick={onSubmitClick}>Submit the form!</button>
</>
}
不使用 useImperativeHandle 实现命令式 API
如果使用 useImperativeHandle hook 让你感到头痛,别担心,我也一样!但是我们实际上不必使用它来实现刚刚实现的功能。我们已经知道了 Refs 如何工作以及它们是可变的事实。因此,我们只需要将我们的 API 对象分配给所需 Ref 的 ref.current
,就像这样:
const InputField = ({ apiRef }) => {
useEffect(() => {
apiRef.current = {
focus: () => {},
shake: () => {},
}
}, [apiRef])
}
这几乎与 useImperativeHandle 在底层执行的操作完全相同。而且它将像之前一样正常工作。
实际上,在这里使用 useLayoutEffect 也许更好,但这是另一篇文章的主题。现在,让我们使用传统的 useEffect。
太棒了,现在我们拥有了一个带有抖动效果的酷炫表单,React 的 refs 不再是一个神秘的问题,而且在 React 中使用命令式 API 真的是件很棒的事情。
但是请记住:Refs 只是一种 “应急通道”,它并不能替代 state 或通过 props 和回调函数进行的正常 React 数据流。只有在没有 “正常” 替代方案的情况下才使用它们。同样,触发某些行为的命令式方式也是如此 —— 更多的场景是使用正常的 props/callbacks 流程来实现。
关于本文
译者:@ikoofe
译文:https://mp.weixin.qq.com/s/8RfYtrJMGgabcgdJg4nETg作者:NADIA MAKAREVICH
原文:https://www.developerway.com/posts/refs-from-dom-to-api
这期前端早读课
对你有帮助,帮” 赞 “一下,
期待下一期,帮” 在看” 一下 。